Анализ пользовательского поведения в мобильном приложении¶

Исходные данные: вы работаете в стартапе, который продаёт продукты питания. Дизайнеры захотели поменять шрифты во всём приложении, а менеджеры испугались, что пользователям будет непривычно. Договорились принять решение по результатам A/A/B-теста. Пользователей разбили на 3 группы: 2 контрольные со старыми шрифтами и одну экспериментальную — с новыми.

Создание двух групп A вместо одной имеет определённые преимущества. Если две контрольные группы окажутся равны, вы можете быть уверены в точности проведенного тестирования. Если же между значениями A и A будут существенные различия, это поможет обнаружить факторы, которые привели к искажению результатов. Сравнение контрольных групп также помогает понять, сколько времени и данных потребуется для дальнейших тестов.

В случае общей аналитики и A/A/B-эксперимента работайте с одними и теми же данными. В реальных проектах всегда идут эксперименты. Аналитики исследуют качество работы приложения по общим данным, не учитывая принадлежность пользователей к экспериментам.

Цель: разобраться, как ведут себя пользователи мобильного приложения по продаже продуктов питания

Задачи:

1) изучить воронку продаж:

  • узнать, как пользователи доходят до покупки;

  • cколько пользователей доходит до покупки;

  • сколько пользователей «застревает» на предыдущих шагах и на каких именно.

2) исследовать результаты A/A/B-эксперимента:

  • выяснить, какой шрифт лучше

1 Загрузка и подготовка данных к анализу¶

In [1]:
# импорт необходимых библиотек
import pandas as pd
from datetime import datetime
import matplotlib.pyplot as plt
from plotly import graph_objects as go
from scipy import stats as st
import numpy as np
import math as mth

import warnings
warnings.filterwarnings('ignore')
In [2]:
logs = pd.read_csv('/Users/olgakozlova/Desktop/datasets/logs_exp.csv', sep = '\t')
logs.head()
Out[2]:
EventName DeviceIDHash EventTimestamp ExpId
0 MainScreenAppear 4575588528974610257 1564029816 246
1 MainScreenAppear 7416695313311560658 1564053102 246
2 PaymentScreenSuccessful 3518123091307005509 1564054127 248
3 CartScreenAppear 3518123091307005509 1564054127 248
4 PaymentScreenSuccessful 6217807653094995999 1564055322 248
In [3]:
# функция для вывода: инфо, числовое описание, количество пропущенных значений, количество полных дубликатов
def df_overview(df):
    print('Общая информация о данных:\n')
    df.info()
    print('\nЧисловое описание данных:\n')
    display(df.describe().T)
    print('\nКоличество пропусков:\n')
    display(df.isna().sum())
    print('\nКоличество полных дубликатов:', df.duplicated().sum())
    
df_overview(logs)
Общая информация о данных:

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 244126 entries, 0 to 244125
Data columns (total 4 columns):
 #   Column          Non-Null Count   Dtype 
---  ------          --------------   ----- 
 0   EventName       244126 non-null  object
 1   DeviceIDHash    244126 non-null  int64 
 2   EventTimestamp  244126 non-null  int64 
 3   ExpId           244126 non-null  int64 
dtypes: int64(3), object(1)
memory usage: 7.5+ MB

Числовое описание данных:

count mean std min 25% 50% 75% max
DeviceIDHash 244126.0 4.627568e+18 2.642425e+18 6.888747e+15 2.372212e+18 4.623192e+18 6.932517e+18 9.222603e+18
EventTimestamp 244126.0 1.564914e+09 1.771343e+05 1.564030e+09 1.564757e+09 1.564919e+09 1.565075e+09 1.565213e+09
ExpId 244126.0 2.470223e+02 8.244339e-01 2.460000e+02 2.460000e+02 2.470000e+02 2.480000e+02 2.480000e+02
Количество пропусков:

EventName         0
DeviceIDHash      0
EventTimestamp    0
ExpId             0
dtype: int64
Количество полных дубликатов: 413
In [4]:
# последние 5 строк таблицы с явными дубликатами
duplicated_logs = logs[logs.duplicated()]
duplicated_logs.tail()
Out[4]:
EventName DeviceIDHash EventTimestamp ExpId
242329 MainScreenAppear 8870358373313968633 1565206004 247
242332 PaymentScreenSuccessful 4718002964983105693 1565206005 247
242360 PaymentScreenSuccessful 2382591782303281935 1565206049 246
242362 CartScreenAppear 2382591782303281935 1565206049 246
242635 MainScreenAppear 4097782667445790512 1565206618 246

Таблица logs состоит из 244126 строк и 4 столбцов. Каждая запись в логе — это действие пользователя или событие. Встречаются следующие типы данных: int (3 раза) и object (1 раз). Пропуски в данных отсутствуют.

Согласно документации к данным:

EventName — название события;

DeviceIDHash — уникальный идентификатор пользователя;

EventTimestamp — время события;

ExpId — номер эксперимента: 246 и 247 — контрольные группы, а 248 — экспериментальная.

Обнаруженные полные дубликаты, составляют меньше 1 % от всех данных и будут удалены:

In [5]:
# удаление явных дубликатов
logs = logs.drop_duplicates().reset_index(drop=True)

# проверка после удаления дублей
logs.shape
Out[5]:
(243713, 4)

Переименуем и приведем к нижнему регистр заголовки столбцов:

In [6]:
print(logs.columns)

# переименование столбцов
logs.columns = ['event_type', 'user_id', 'event_timestamp', 'exp_id']
logs.columns
Index(['EventName', 'DeviceIDHash', 'EventTimestamp', 'ExpId'], dtype='object')
Out[6]:
Index(['event_type', 'user_id', 'event_timestamp', 'exp_id'], dtype='object')

В столбце 'event_timestamp' дата и время записаны в формате unix timestamp. Переведем данные в формат datetime, выделив их в отдельный столбец 'event_datetime', создадим отдельный столбец с датами 'event_date':

In [7]:
# добавление столбца даты и времени
logs['event_datetime'] = pd.to_datetime(logs['event_timestamp'], unit = 's')

# добавление столбца дат
logs['event_date'] = pd.to_datetime(logs['event_datetime']).dt.date
logs.head()
Out[7]:
event_type user_id event_timestamp exp_id event_datetime event_date
0 MainScreenAppear 4575588528974610257 1564029816 246 2019-07-25 04:43:36 2019-07-25
1 MainScreenAppear 7416695313311560658 1564053102 246 2019-07-25 11:11:42 2019-07-25
2 PaymentScreenSuccessful 3518123091307005509 1564054127 248 2019-07-25 11:28:47 2019-07-25
3 CartScreenAppear 3518123091307005509 1564054127 248 2019-07-25 11:28:47 2019-07-25
4 PaymentScreenSuccessful 6217807653094995999 1564055322 248 2019-07-25 11:48:42 2019-07-25

1.1 Промежуточные выводы¶

Таблица logs хранит лог сервера с данными о действиях пользователей мобильного приложения по продаже продуктов питания и состоит из 244126 строк и 4 столбцов. Встречаются следующие типы данных: int (3 раза) и object (1 раз).

Пропуски в данных отсутствуют. Найдено и удалено 413 полных дубликатов, которые составляли меньше 1 % от всех данных.

Заголовки столбцов переименованы и приведены к нижнему регистру.

Добавлен столбец даты и времени, а также столбец времени в формате datetime.

2 Изучение и проверка данных¶

In [8]:
# количество событий в логе
events_count = logs['user_id'].count()
print(f"Количество событий в логе: {events_count}")
Количество событий в логе: 243713
In [9]:
# количество уникальных пользователей в логе
users_nunique = logs['user_id'].nunique()
print(f"Количество уникальных пользователей в логе: {users_nunique}")
Количество уникальных пользователей в логе: 7551
In [10]:
# среднее количество событий на пользователя (округление в большую сторону)
print(f"Количество событий на пользователя: {round(events_count/users_nunique,0)}")
Количество событий на пользователя: 32.0

Всего в логе 243713 событий и 7551 уникальный пользователь. В среднем на пользователя приходится 32 события.

In [11]:
# минимальная дата
display(logs['event_datetime'].min())

# максимальная дата
logs['event_datetime'].max()
Timestamp('2019-07-25 04:43:36')
Out[11]:
Timestamp('2019-08-07 21:15:17')

В логе хранятся данные с 25.07.2019 по 07.08.2019 включительно.

In [12]:
# столбчатая диаграмма 
logs.groupby('event_date').agg({'event_type': 'count'}).plot(kind = 'bar', figsize = (15, 5))
plt.title('Распределение событий во времени')
plt.xlabel('Дата')
plt.ylabel('Количество событий')
plt.xticks(rotation = 30)
plt.show()

Исходные данные не одинаково полные за весь период. Технически в логи новых дней по некоторым пользователям могут «доезжать» события из прошлого — это может «перекашивать данные». Не хватает данных за первую неделю, т.е. с 25.07.2019 до 31.07.2019 включительно. Отбросив эти данные, получим, что на самом деле, располагаем данными с 01.08.2019 по 07.08.2019 включительно. Cкорее всего 01.08.2019 - первый день эксперимента.

In [13]:
event_date = datetime.strptime('2019-07-31', '%Y-%m-%d').date()
In [14]:
# оставим свежие данные с 01.08.2019
logs = logs.query('event_date > @event_date')
logs.head()
Out[14]:
event_type user_id event_timestamp exp_id event_datetime event_date
2826 Tutorial 3737462046622621720 1564618048 246 2019-08-01 00:07:28 2019-08-01
2827 MainScreenAppear 3737462046622621720 1564618080 246 2019-08-01 00:08:00 2019-08-01
2828 MainScreenAppear 3737462046622621720 1564618135 246 2019-08-01 00:08:55 2019-08-01
2829 OffersScreenAppear 3737462046622621720 1564618138 246 2019-08-01 00:08:58 2019-08-01
2830 MainScreenAppear 1433840883824088890 1564618139 247 2019-08-01 00:08:59 2019-08-01

Количество событий в логе после удаления строк составило 240887.

In [15]:
# количество строк (событий) после фильтрации
events_count_after = logs['user_id'].count()
print(f"Количество событий после удаления строк: {events_count_after}")

# потеряные события (строки) в процентах
print(f"Процент потерянных событий после удаления строк: {(events_count - events_count_after)/events_count:.2%}")

print()

# количество уникальных пользователей после фильтрации
users_nunique_after = logs['user_id'].nunique()
print(f"Количество пользователей после удаления строк: {users_nunique_after}")

# потерянные пользователи в процентах
print(f"Процент потерянных пользователей после удаления строк: {(users_nunique - users_nunique_after)/users_nunique:.2%}")

print()

# среднее количество событий на пользователя после фильтрации
print(f"Среднее количество событий на пользователя после удаления строк: {round(events_count_after/users_nunique_after,0)}")
Количество событий после удаления строк: 240887
Процент потерянных событий после удаления строк: 1.16%

Количество пользователей после удаления строк: 7534
Процент потерянных пользователей после удаления строк: 0.23%

Среднее количество событий на пользователя после удаления строк: 32.0

После фильтрации утеряно небольшое количество событий и пользователей.

Проверим, присутствуют ли в оставшихся данных пользователи из всех трёх экспериментальных групп:

In [16]:
# количество пользователей в экспериментальных группах
logs.groupby('exp_id')['user_id'].nunique()
Out[16]:
exp_id
246    2484
247    2513
248    2537
Name: user_id, dtype: int64

Пользователи равномерно распределены между всем группами.

2.1 Промежуточные выводы¶

Изначально в логе хранились события за две недели: с 25.07.2019 по 07.08.2019 включительно. Однако построенная столбчатая диаграмма показала 'перекошенность данных', что позволило сделать вывод неполноте данных за весь период. Более старые данные (до 01.08.2019) были отброшены, в логе осталась информация только за вторую неделю (01.08.2019 - 07.08.2019).

После фильтрации было утеряно небольшое количество событий (1.2 %) и пользователей (0.2 %). Все оставшиеся пользователи равномерно распределены между тремя экспериментальными группами.

3 Изучение воронки событий¶

Посмотрим, какие события есть в логах, и как часто они встречаются:

In [17]:
# встречаемость событий в логах
logs['event_type'].value_counts()
Out[17]:
MainScreenAppear           117328
OffersScreenAppear          46333
CartScreenAppear            42303
PaymentScreenSuccessful     33918
Tutorial                     1005
Name: event_type, dtype: int64

В логах встречаются следующие события:

1) Отображение пользователю главной страницы (MainScreenAppear)

2) Отображение пользователю страницы предложений (OffersScreenAppear)

3) Отображение пользователю страницы корзины (CartScreenAppear)

4) Страница успешной оплаты (PaymentScreenSuccessful)

5) Туториал (Tutorial).

Наиболее часто встречаются события с отображением пользователю главной страницы приложения.

Посчитаем, сколько пользователей совершали каждое из этих событий:

In [18]:
# количество пользователей совершивших каждое из этих событий
logs_funnel = logs.groupby('event_type')['user_id'].nunique().reset_index().rename(columns = {'user_id': 'user_count'}).sort_values(by = 'user_count', ascending = False)

# добавление строки Все пользователи
logs_funnel = logs_funnel.append({'event_type': 'AllUsers', 'user_count': logs['user_id'].nunique()}, ignore_index=True).sort_values(by = 'user_count', ascending = False)

# процент пользователей, хоть раз совершивших событие
logs_funnel['percent'] = round((logs_funnel['user_count'] / logs['user_id'].nunique() * 100), 1)
logs_funnel
Out[18]:
event_type user_count percent
5 AllUsers 7534 100.0
0 MainScreenAppear 7419 98.5
1 OffersScreenAppear 4593 61.0
2 CartScreenAppear 3734 49.6
3 PaymentScreenSuccessful 3539 47.0
4 Tutorial 840 11.1

Можно предположить, что события происходят в следующем порядке: Туториал → Отображение пользователю главной страницы → Отображение пользователю страницы предложений → Отображение пользователю страницы корзины → Страница успешной оплаты.

Туториал не встраивается в цепочку, полученную выше. Многие пользователи просто пропускают этот шаг. Не будем его учитывать при расчете воронки:

In [19]:
# исключение из данных события Туториал
logs = logs[logs['event_type'] != "Tutorial"]

Рассчитаем воронку с учетом изменений количества событий, возможно могло измениться количество всех уникальных пользователей:

In [20]:
# количество пользователей совершивших каждое из этих событий 
logs_funnel = logs.groupby('event_type')['user_id'].nunique().reset_index().rename(columns = {'user_id': 'user_count'}).sort_values(by = 'user_count', ascending = False)

# добавление строки Все пользователи без события Туториал
logs_funnel = logs_funnel.append({'event_type': 'AllUsers', 'user_count': logs['user_id'].nunique()}, ignore_index=True).sort_values(by = 'user_count', ascending = False)

# процент пользователей, хоть раз совершивших событие
logs_funnel['percent'] = round((logs_funnel['user_count'] / logs['user_id'].nunique() * 100),1)
logs_funnel
Out[20]:
event_type user_count percent
4 AllUsers 7530 100.0
0 MainScreenAppear 7419 98.5
1 OffersScreenAppear 4593 61.0
2 CartScreenAppear 3734 49.6
3 PaymentScreenSuccessful 3539 47.0

Количество уникльных пользователей сократилось до 7530. Построим воронку событий:

In [21]:
# воронка событий plotly
fig = go.Figure(
    go.Funnel(
        y = logs_funnel['event_type'],
        x = logs_funnel['user_count'],
        textinfo = "value+percent previous+percent initial",
    )
)
fig.update_layout(
    title={
        'text': 'Воронка событий',
        'y':0.9,
        'x':0.5,
        'xanchor': 'center',
        'yanchor': 'top'
    }
)
fig.show()

По воронке событий наглядно видно, какая доля пользователей проходит на следующий шаг воронки (от числа пользователей на предыдущем).

Больше всего пользователей теряется на шаге 'Отображение пользователю главной страницы' - около 38 %.

От первого события до оплаты доходит примерно 47 % пользователей.

3.1 Промежуточные выводы¶

1) Сделано предположение, что события происходят в следующем порядке: Туториал → Отображение пользователю главной страницы → Отображение пользователю страницы предложений → Отображение пользователю страницы корзины → Страница успешной оплаты. Так как многие пользователи пропускают шаг туториал, при расчете воронки он не учитывается;

2) По построенной воронке событий видно, что:

  • 1.5 % всех пользователей не доходят до главного экрана, вероятно, тут имеет место техническая ошибка;

  • больше всего пользователей теряется на шаге 'Отображение пользователю главной страницы' - около 38 %;

  • от первого события до оплаты доходит примерно 47 % пользователей.

4 Изучение результатов эксперимента¶

Посмотрим, сколько пользователей в каждой экспериментальной группе:

In [22]:
# количество пользователей в каждой экспериментальной группе
trials = logs.groupby('exp_id').agg({'user_id': 'nunique'})
display(trials)

# круговая диаграмма
with plt.style.context('seaborn'):
    trials.plot.pie(y = 'user_id',
        title="Распределение пользователей по экспериментальным группам", legend=False, autopct='%1.1f%%', startangle=0
    )
user_id
exp_id
246 2483
247 2512
248 2535

Между группами пользователи распределены равномерно, в каждой из трех групп примерно по 2500 пользователей.

Построим воронку событий по экспериментальным группам:

In [23]:
# количество пользователей в каждой экспериментальной группе, аналог таблице выше, но транспонированная
all_users_by_exp = logs.pivot_table(columns = 'exp_id', values = 'user_id', aggfunc = 'nunique').reset_index()
all_users_by_exp.columns = ['event_type', '246', '247', '248']

# воронка событий в разбивке по 3 экспериментам
funnel = logs.pivot_table(index = 'event_type', columns = 'exp_id', values = 'user_id', aggfunc = 'nunique').reset_index()
funnel.columns = ['event_type', '246', '247', '248']
funnel = funnel.sort_values(by = '246', ascending=False)
display(funnel)

# объединение воронки и таблицы с общим количеством пользователей
funnel_with_allusers = pd.concat([funnel, all_users_by_exp], axis = 0).sort_values(by = '248', ascending = False).reset_index(drop=True)
funnel_with_allusers.loc[funnel_with_allusers['event_type'] == "user_id", ['event_type']] = 'AllUsers'
funnel_with_allusers
event_type 246 247 248
1 MainScreenAppear 2450 2476 2493
2 OffersScreenAppear 1542 1520 1531
0 CartScreenAppear 1266 1238 1230
3 PaymentScreenSuccessful 1200 1158 1181
Out[23]:
event_type 246 247 248
0 AllUsers 2483 2512 2535
1 MainScreenAppear 2450 2476 2493
2 OffersScreenAppear 1542 1520 1531
3 CartScreenAppear 1266 1238 1230
4 PaymentScreenSuccessful 1200 1158 1181
In [24]:
# воронка событий plotly
fig = go.Figure()
fig.add_trace(go.Funnel(
        name = 'exp_246',
        y = funnel_with_allusers['event_type'],
        x = funnel_with_allusers['246'],
        textinfo = "value+percent previous+percent initial"))

fig.add_trace(go.Funnel(
        name = 'exp_247',
        y = funnel_with_allusers['event_type'],
        x = funnel_with_allusers['247'],
        textinfo = "value+percent previous+percent initial"))

fig.add_trace(go.Funnel(
        name = 'exp_248',
        y = funnel_with_allusers['event_type'],
        x = funnel_with_allusers['248'],
        textinfo = "value+percent previous+percent initial",
        textposition = "inside"))

fig.update_layout(
    title={
        'text': 'Воронка событий по трём экспериментам',
        'y':0.9,
        'x':0.5,
        'xanchor': 'center',
        'yanchor': 'top',       
    }
)

fig.show()

Самое популярное событие - показ пользователю главной страницы (MainScreenAppear). В группе 246 его совершили 2450 пользователей (99 %), в 247: 2476 пользователей (99 %), в 298: 2493 пользователя (98 %).

Проведем проверку на присутствие пользвателей сразу в нескольких группах.

In [25]:
# уникальные пользователи, состоящие в нескольких группах одновременно 
logs.groupby('user_id')['exp_id'].agg('nunique').reset_index().query('exp_id > 1')
Out[25]:
user_id exp_id

Пользователи присутствующие сразу в нескольких группах не обнаружены.

Есть 2 контрольные группы для А/А-эксперимента, чтобы проверить корректность всех механизмов и расчётов. Проверим, находят ли статистические критерии разницу между выборками 246 и 247 для всех событий. Для этого проведем z-тест. Сформулируем нулевую и альтернативную гипотезы:

Н0 (Нулевая гипотеза): между выборками 246 и 247 нет отличий в доле пользователей, совершивших событие Х;

Н1 (Альтернативная гипотеза): между выборками 246 и 247 есть отличия в доле пользователей, совершивших событие Х.

Примем критический уровень статистической знаимости (alpha) равным 0.05.

In [26]:
# воронка без строки все уникальные пользователи
funnel = logs.pivot_table(index = 'event_type', columns = 'exp_id', values = 'user_id', aggfunc = 'nunique').sort_values(by = 248, ascending = False)

# добавление столбца объединенная контрольная группа 246+247
funnel['246+247'] = funnel[246] + funnel[247]
display(funnel)
exp_id 246 247 248 246+247
event_type
MainScreenAppear 2450 2476 2493 4926
OffersScreenAppear 1542 1520 1531 3062
CartScreenAppear 1266 1238 1230 2504
PaymentScreenSuccessful 1200 1158 1181 2358
In [27]:
# количество пользователей в каждой экспериментальной группе
trials = trials.reset_index()
trials = trials.append({'exp_id': '246+247', 'user_id': 4995}, ignore_index=True) # добавление строки 246+247
trials = trials.set_index(trials.columns[0])
display(trials)
user_id
exp_id
246 2483
247 2512
248 2535
246+247 4995

Создадим функцию для проверки отличия между группами:

In [28]:
# исследование отличий для групп 246 и 247 по событиям
def z_test (exp1, exp2, event, alpha):
    successes1 = funnel.loc[event, exp1]
    successes2 = funnel.loc[event, exp2]
    trials1 = trials.loc[exp1, 'user_id']
    trials2 = trials.loc[exp2, 'user_id']
    # пропорция успехов в обеих группах:
    p1 = successes1/trials1
    p2 = successes2/trials2
    # пропорция успехов в комбинированном датасете:
    p_combined = (successes1 + successes2) / (trials1 + trials2)
    # разница пропорций в датасетах
    difference = p1 - p2
    # считаем статистику в ст.отклонениях стандартного нормального распределения
    z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials1 + 1/trials2))
    # задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
    distr = st.norm(0, 1)  
    p_value = (1 - distr.cdf(abs(z_value))) * 2
    print('Для групп {} и {} по событию {} p-значение: {p_value:.2f}'.format(exp1, exp2, event, p_value=p_value))
    if (p_value < alpha):
        print("Отвергаем нулевую гипотезу: между долями есть значимая разница")
    else:
        print("Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными")
In [29]:
# вызов функции z-test для 246 и 247 групп
for event in funnel.index:
    z_test(246, 247, event, 0.05)
    print()
Для групп 246 и 247 по событию MainScreenAppear p-значение: 0.75
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Для групп 246 и 247 по событию OffersScreenAppear p-значение: 0.25
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Для групп 246 и 247 по событию CartScreenAppear p-значение: 0.23
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Для групп 246 и 247 по событию PaymentScreenSuccessful p-значение: 0.11
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Во всех тестах групп 246 и 247 не получилось отвергнуть нулевую гипотезу. Это говорит о том, что между долями нет разницы и разбиение на 2 контрольные группы для А/А-эксперимента работает корректно. Переходим к А/В-тестированию.

Аналогично проведем тесты для группы с измененным шрифтом (248). Сравним результаты с каждой из контрольных групп в отдельности по каждому событию. Сравним результаты с объединённой контрольной группой.

Сформулируем нулевую и альтернативную гипотезы для сравнения первой контрольной группы 246 и группы с измененным шрифтом 248:

Н0 (Нулевая гипотеза): между выборками 246 и 248 нет отличий в доле пользователей, совершивших событие Х;

Н1 (Альтернативная гипотеза): между выборками 246 и 248 есть отличия в доле пользователей, совершивших событие Х.

Критический уровень статистической знаимости (alpha) примем равным 0.05.

In [30]:
# вызов функции z-test для 246 и 248 групп
for event in funnel.index:
    z_test(246, 248, event, 0.05)
    print()
Для групп 246 и 248 по событию MainScreenAppear p-значение: 0.34
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Для групп 246 и 248 по событию OffersScreenAppear p-значение: 0.21
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Для групп 246 и 248 по событию CartScreenAppear p-значение: 0.08
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Для групп 246 и 248 по событию PaymentScreenSuccessful p-значение: 0.22
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Для групп А (246) и В (248) по всем событиям разница в долях пользователей не обнаружена.

Нулевая и альтернативная гипотезы для сравнения второй контрольной группы 247 и группы с измененным шрифтом 248:

Н0: между выборками 247 и 248 нет отличий в доле пользователей, совершивших событие Х;

Н1: между выборками 247 и 248 есть отличия в доле пользователей, совершивших событие Х.

In [31]:
# вызов функции z-test 247 и 248
for event in funnel.index:
    z_test(247, 248, event, 0.05)
    print()
Для групп 247 и 248 по событию MainScreenAppear p-значение: 0.52
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Для групп 247 и 248 по событию OffersScreenAppear p-значение: 0.93
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Для групп 247 и 248 по событию CartScreenAppear p-значение: 0.59
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Для групп 247 и 248 по событию PaymentScreenSuccessful p-значение: 0.73
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

У групп А (247) и В (248) статистической значимости между долями нет.

Проведем сравнение между объединенной контрольной группой А (246+247) и группой В (248). Нулевая и альтернативная гипотезы:

Н0: между выборками 246+247 и 248 нет отличий в доле пользователей, совершивших событие Х;

Н1: между выборками 246+247 и 248 есть отличия в доле пользователей, совершивших событие Х.

In [32]:
# вызов функции z-test объединенная контрольная группа и 248
for event in funnel.index:
    z_test('246+247', 248, event, 0.05)
    print()
Для групп 246+247 и 248 по событию MainScreenAppear p-значение: 0.35
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Для групп 246+247 и 248 по событию OffersScreenAppear p-значение: 0.45
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Для групп 246+247 и 248 по событию CartScreenAppear p-значение: 0.19
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Для групп 246+247 и 248 по событию PaymentScreenSuccessful p-значение: 0.61
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

При сравнении объединенной контрольной группы (246+247) и группы с измененным шрифтом (248) различия в долях пользователей, совершивших событие Х, не выявлены.

В исследовании проведено 16 проверок, уровень значимости был принят равным 0.05. Чтобы снизить вероятность ложнопозитивного результата при множественном тестировании гипотез, применяют разные методы корректировки уровня значимости для уменьшения FWER. Из-за простоты решения применим поправку Бонфферони:

In [33]:
print('Поправка Бонфферони для 16 сравнений с уровнем значимости 0.05:', 0.05/16)
Поправка Бонфферони для 16 сравнений с уровнем значимости 0.05: 0.003125

Не будем повторять тесты с использованием нового уровня значимости 0.003125, так как значение p_value во всех случаях достаточно большое - всегда с запасом выше 0.05, а значит будет точно выше 0,003125.

После применения поправки Бонфферони результат остается неизменным: для всех событий статистической разницы между долями нет.

4.1 Промежуточные выводы¶

1) В каждой экспериментальной группе примерно по 2500 пользователей, т.е пользователи между группами распределены равномерно;

2) По всем событиям для контрольных групп (246 и 247) статистические отличия между долями пользователей не выявлены;

3) По всем событиям для контрольной группы А (246) и группы с измененным шрифтом В (248) статистической значимости между долями нет;

4) По всем событиям для контрольной группы А (247) и группы с измененным шрифтом В (248) статистической значимости между долями нет;

5) По всем событиям для объединенной контрольной группы (246+247) и группы с измененным шрифтом В (248) статистической значимости между долями нет;

6) Использование поправки Бонфферони не изменило результатов тестов;

7) Таким образом, можно сделать вывод, что между контрольными группами А и группой с измененным шрифтом В отсутствуют отличия.

5 Выводы¶

При подготовке данных к анализу:

  • удалено 413 полных дубликатов, которые составляли меньше 1 % от всех данных;

  • заголовки столбцов переименованы и приведены к нижнему регистру;

  • добавлен столбец даты и времени, а также столбец времени в формате datetime.

При изучении и проверке данных:

  • данные до 01.08.2019 года были отброшены, в логе осталась информация только за вторую неделю (01.08.2019 - 07.08.2019);

  • после фильтрации утеряно небольшое количество событий (1.2 %) и пользователей (0.2 %).

При изучении воронки событий:

  • сделано предположение, что события происходят в следующем порядке: Туториал → Отображение пользователю главной страницы → Отображение пользователю страницы предложений → Отображение пользователю страницы корзины → Страница успешной оплаты. Так как многие пользователи пропускают шаг туториал, при расчете воронки он не учитывается;

  • выявлено, что 1.5 % всех пользователей не доходят до главного экрана, вероятно, тут имеет место техническая ошибка;

  • выявлено, что больше всего пользователей теряется на шаге 'отображение пользователю главной страницы' - около 38 %. Стоит обратить на это внимание и выяснить, почему эти пользователи не доходят шага 'отображение пользователю страницы предложений';

  • выявлено, что от первого события до оплаты доходит примерно 47 % пользователей.

При изучении результатов эксперимента:

  • определено, что пользователи между группами распределены равномерно (примерно по 2500 пользователей на группу);

  • можно сделать вывод, что между контрольными группами А и группой с измененным шрифтом В отсутствуют отличия, то есть изменение шрифта в приложении статистически значимого влияние на конверсию ни на одном уровне не оказывает;

  • рекомендовано, не вводить изменение шрифта.